1
0
Files
shell/claude.md
2026-02-27 12:22:50 +09:00

6.7 KiB

aishell

A single-stream shell where commands and AI coexist.

Architecture

┌──────────────────────────────────────────────┐
│                  aishell                      │
│                                               │
│  ┌──────────┐    judge     ┌───────────────┐ │
│  │ rustyline │───────────→│ sh -c command  │ │
│  │ (input)   │  is_command  └───────────────┘ │
│  └──────────┘                                 │
│       │ !is_command                           │
│       ▼                                       │
│  ┌──────────┐  stdin(JSON)  ┌──────────────┐ │
│  │  send()  │─────────────→│ claude        │ │
│  │ (async)  │               │ (persistent)  │ │
│  └──────────┘               │ stream-json   │ │
│                              └──────────────┘ │
│  ┌──────────┐  stdout(JSON)       │          │
│  │ reader   │←────────────────────┘          │
│  │ thread   │  mpsc channel                   │
│  └──────────┘                                 │
│       │                                       │
│       ▼                                       │
│  ┌──────────┐                                 │
│  │ drain()  │──→ println! (unified stream)    │
│  └──────────┘                                 │
│                                               │
│  ┌───────────────────────────────────────┐    │
│  │ ● [1] responding... | [2] thinking... │    │
│  └───────────────────────────────────────┘    │
└──────────────────────────────────────────────┘

Claude Process (ai.rs)

Startup

One persistent process for the entire session:

claude --input-format stream-json --output-format stream-json \
       --verbose --dangerously-skip-permissions
  • --input-format stream-json: accepts JSON lines on stdin
  • --output-format stream-json: emits JSON events on stdout
  • Process stays alive until aishell exits. No restart per message.

Input Format

{"type":"user","message":{"role":"user","content":"user input here"}}

Written to claude's stdin via Arc<Mutex<ChildStdin>>.

Output Events

type: "system"     → init event (tools list, MCP servers, model info)
type: "assistant"  → response content
  content[].type: "text"     → AI text (accumulated)
  content[].type: "tool_use" → tool execution (name shown in status)
type: "result"     → turn complete (final text, cost, usage)

Thread Model

Main Thread                          Background Thread
    │                                      │
    ├─ readline()                          ├─ BufReader::lines() on claude stdout
    ├─ judge::is_command()                 ├─ serde_json::from_str() each line
    ├─ command → executor::execute()       ├─ "assistant" → accumulate text, update status
    ├─ AI → claude.send() (non-blocking)   ├─ "tool_use" → update status
    ├─ drain_responses() before prompt     ├─ "result" → mpsc::send() completed text
    └─ status.set(status_line())           └─ remove session from status vec

Polling Thread (200ms interval)
    ├─ try_recv() completed responses → print immediately
    └─ update status bar

Shared State

stdin:     Arc<Mutex<ChildStdin>>         // main → claude stdin writes
status:    Arc<Mutex<Vec<SessionStatus>>> // both threads read/write status
output_tx: mpsc::Sender<String>           // background → main completed responses
output_rx: mpsc::Receiver<String>         // main drains with try_recv()
id_tx:     mpsc::Sender<usize>            // main → background session ID assignment

Non-blocking Send

pub fn send(&mut self, input: &str) -> usize {
    let id = self.next_id;          // assign session ID
    self.next_id += 1;
    status.push(SessionStatus { id, state: "thinking..." });
    self.id_tx.send(id);            // notify reader thread
    writeln!(stdin, "{}", json);    // write JSON to claude stdin
    stdin.flush();
    // returns immediately — does NOT wait for response
    id
}

Input Detection (judge.rs)

Priority order:

  1. Shell operators (|, >, <, ;, &) outside quotes → shell
  2. Variable assignment (FOO=bar) → shell
  3. Shell builtins (cd, echo, export, etc. — 50 builtins) → shell
  4. Absolute/relative path to existing file → shell
  5. Command found in PATH → shell
  6. None of the above → AI

Quote-aware: operators inside '...' or "..." are ignored.

Command Execution (executor.rs)

  • cdenv::set_current_dir() (changes process directory)
  • cd - → OLDPWD support, ~ → HOME expansion
  • Everything else → sh -c "input" (pipes, redirects, globs all work)

Status Bar (status.rs)

Terminal last line reserved for Claude status:

● idle                                    ← waiting
● [1] thinking...                         ← processing
● [1] responding...                       ← generating text
● [1] running: Bash...                    ← executing tool
● [1] responding... | [2] thinking...     ← multiple sessions

Implementation:

  • ANSI escape \x1b[1;{rows-1}r sets scroll region excluding last line
  • \x1b7 save cursor → draw status on last row → \x1b8 restore cursor
  • Auto-cleanup on Drop (reset scroll region)

Files

src/
├── main.rs       Main loop: input → judge → execute/AI → drain responses
├── lib.rs        Module declarations
├── ai.rs         ClaudeManager: persistent process, async send/receive
├── judge.rs      is_command(): input classification (6 tests)
├── executor.rs   execute(): sh -c + cd handling
└── status.rs     StatusBar: ANSI last-line status display

Dependencies

rustyline = "14.0"       # line input + history
serde = "1"              # JSON serialization
serde_json = "1"         # stream-json protocol parsing
terminal_size = "0.4"    # terminal dimensions for status bar