diff --git a/README.md b/README.md index 31164e8..4b85be5 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,29 @@ A full-screen TUI CLI agent powered by local LLMs via [Docker Model Runner](http paikea ``` +## Documentation + +Full docs live in [`docs/`](docs/index.md), organized with the +[Diátaxis](https://diataxis.fr) framework: + +- **[Tutorial — Getting started](docs/tutorials/getting-started.md)** +- **How-to** — [connection](docs/how-to/configure-dmr-connection.md) · + [speed](docs/how-to/speed-up-responses.md) · + [workflow](docs/how-to/drive-the-workflow.md) · + [appearance](docs/how-to/customize-appearance.md) +- **Reference** — [configuration](docs/reference/configuration.md) · + [keybindings](docs/reference/keybindings.md) · + [CLI](docs/reference/cli.md) · [tools](docs/reference/tools.md) +- **Explanation** — [architecture](docs/explanation/architecture.md) · + [workflow frise](docs/explanation/workflow-frise.md) · + [speed & context](docs/explanation/speed-and-context.md) + ## Features - **Full-screen TUI** — React/Ink components with flexbox layout - **Local LLMs** — streams via Docker Model Runner (OpenAI-compatible API at `localhost:12434`) - **Model switching** — Tab / Shift+Tab cycles through available models, or `M` to pick from a list +- **Marine themes** — five ocean palettes (`deep-sea`, `dawn`, `storm`, `lagoon`, `polar-night`) with nautical iconography, switchable from the command palette - **Thinking support** — detects and renders chain-of-thought from Qwen3, DeepSeek-R1, OpenAI o-series - **OpenSpec integration** — detects propose → plan → design → tasks → apply → archive workflow - **Skills & rules** — loads from bundled defaults + `.paikea/` + `.claude/` project overrides @@ -19,11 +37,49 @@ paikea - **Diátaxis docs** — `paikea doc` generates tutorials, how-to, reference, and explanation documentation - **Single binary** — compiles to a self-contained executable via `bun build --compile` +## Configuration + +paikea reads `~/.paikea/config.json`. All fields are optional: + +```json +{ + "theme": "deep-sea", + "dmrScheme": "http", + "dmrHost": "localhost", + "dmrPort": 12434, + "thinking": false +} +``` + +| Field | Default | Description | +|-------|---------|-------------| +| `theme` | terminal-detected | Active marine theme (see below) | +| `dmrScheme` | `http` | Scheme of the Docker Model Runner API. Only `https` overrides the default. | +| `dmrHost` | `localhost` | Host of the DMR API (hostname or IP, no scheme). A blank value falls back to the default. | +| `dmrPort` | `12434` | Port of the DMR API. An out-of-range or non-integer value falls back to the default. | +| `thinking` | `false` | Whether thinking-capable models reason before answering. Left off for faster, direct responses; set `true` (or toggle from `Ctrl+P`) to re-enable chain-of-thought. | + +The scheme/host/port form the base URL `://:/engines/v1` — point paikea at a remote or containerised runner by setting these. + +### Speed & context + +Responses are gated by two things on small local models: prompt-eval time +(how many tokens the model must read every turn) and the model's context +window. paikea keeps the system prompt small — skill instructions are loaded +on demand via the `read_skill` tool rather than injected in full — and leaves +model reasoning off by default (`thinking: false`). The context window itself +is fixed when Docker Model Runner loads the model; raise it with: + +```bash +docker model configure --context-size 16384 +``` + ## Prerequisites - [Bun](https://bun.sh) (v1.3+) - [Docker Desktop](https://www.docker.com/products/docker-desktop/) with Model Runner enabled - At least one model pulled (e.g. `ai/qwen3`) +- _(optional)_ [OpenSpec](https://github.com/Fission-AI/OpenSpec) CLI — `npm i -g @fission-ai/openspec` — to drive the workflow frise. Without it, paikea runs as a plain chat with a single **Discuss** step. ## Install @@ -58,14 +114,28 @@ paikea | `Enter` | Submit prompt | | `Escape` | Cancel generation (while streaming) · Quit (when idle) | | `Ctrl+C` / `Ctrl+D` | Cancel generation, or quit | -| `Tab` | Accept suggestion, or next model | +| `Tab` | Accept suggestion, or next model when there is none | | `Shift+Tab` | Previous model | -| `↑` | Open toolbar | -| `:` | Open command palette | +| `Ctrl+P` | Open command palette (models, themes, steps…) | +| `/discuss`, `/proposal`, `/design`, `/specs`, `/tasks`, `/apply`, `/archive` | Switch OpenSpec step — type the command (Tab autocompletes) and press Enter. A unique prefix works too (`/pro` → proposal). | +| `Alt+←`/`Alt+→` | Previous / next OpenSpec step (if your terminal forwards Alt+arrows) | | `←/→` | Move cursor in prompt | | `Home`/`End` · `Ctrl+A`/`Ctrl+E` | Jump to start/end of prompt | | `Ctrl+W` / `Ctrl+U` | Delete previous word / to line start | -| `PageUp`/`PageDown` · `Shift+↑`/`Shift+↓` | Scroll the response | +| `↑`/`↓` · `PageUp`/`PageDown` · `Shift+↑`/`Shift+↓` | Scroll the focused pane (anchored to the latest output) | +| `Ctrl+T` | Switch scroll focus between the thinking and response panes | + +The workflow frise is read from the OpenSpec CLI: **Discuss** (a free-form +step that never touches OpenSpec) followed by the active change's real +artifacts (`Proposal → Design → Specs → Tasks`, from `openspec status`), then +`Apply` (task implementation progress) and `Archive`. paikea tracks the most +recently modified in-progress change; if the CLI isn't installed or the repo +isn't an OpenSpec project, only the **Discuss** step is shown. + +The selected step drives the session: prompt suggestions, the step-matching +`openspec-*` skills injected into the system prompt, and step-specific +guidance (`openspec new change`, `openspec validate`, `openspec archive`…). It +defaults to the current step and refreshes after each turn. ### Initialize a project diff --git a/src/__tests__/openspec-hook.test.ts b/src/__tests__/openspec-hook.test.ts index f041369..028470f 100644 --- a/src/__tests__/openspec-hook.test.ts +++ b/src/__tests__/openspec-hook.test.ts @@ -1,39 +1,55 @@ import { describe, expect, it } from "bun:test"; -import { detectOpenSpecSteps } from "../services/openspec-hook"; +import { + buildStepPrompt, + detectOpenSpecSteps, +} from "../services/openspec-hook"; describe("detectOpenSpecSteps", () => { - it("should return all pending steps when no openspec dir exists", () => { + it("falls back to a single discuss step outside an OpenSpec project", () => { + // No CLI / not an OpenSpec project → chat-only workflow const steps = detectOpenSpecSteps("/nonexistent/path"); - expect(steps).toHaveLength(7); + expect(steps).toHaveLength(1); + expect(steps[0]?.id).toBe("discuss"); expect(steps[0]?.status).toBe("current"); - expect(steps.slice(1).every((s) => s.status === "pending")).toBe(true); }); - it("should return correct step structure", () => { - const steps = detectOpenSpecSteps("/nonexistent/path"); - const ids = steps.map((s) => s.id); - expect(ids).toEqual([ - "discuss", - "propose", - "plan", - "design", - "tasks", - "apply", - "archive", - ]); + it("always starts the workflow with a non-OpenSpec discuss step", () => { + const steps = detectOpenSpecSteps(process.cwd()); + expect(steps[0]?.id).toBe("discuss"); }); - it("should have correct step names", () => { - const steps = detectOpenSpecSteps("/nonexistent/path"); - const names = steps.map((s) => s.name); - expect(names).toEqual([ - "Discuss", - "Propose", - "Plan", - "Design", - "Tasks", - "Apply", - "Archive", - ]); + it("exposes exactly one current step", () => { + const steps = detectOpenSpecSteps(process.cwd()); + expect(steps.filter((s) => s.status === "current")).toHaveLength(1); + }); + + it("orders discuss before any OpenSpec artifact steps", () => { + const steps = detectOpenSpecSteps(process.cwd()); + const discussIdx = steps.findIndex((s) => s.id === "discuss"); + const proposalIdx = steps.findIndex((s) => s.id === "proposal"); + // In this repo OpenSpec is set up, so proposal should be present and after discuss + if (proposalIdx >= 0) { + expect(discussIdx).toBeLessThan(proposalIdx); + } + }); +}); + +describe("buildStepPrompt", () => { + it("tells the model NOT to touch OpenSpec during discuss", () => { + const prompt = buildStepPrompt("discuss"); + expect(prompt).toContain("Workflow"); + expect(prompt.toLowerCase()).toContain("do not"); + expect(prompt.toLowerCase()).toContain("openspec"); + }); + + it("mentions the matching openspec command for each openspec step", () => { + expect(buildStepPrompt("proposal")).toContain("openspec new change"); + expect(buildStepPrompt("specs")).toContain("openspec validate"); + expect(buildStepPrompt("apply")).toContain("openspec validate"); + expect(buildStepPrompt("archive")).toContain("openspec archive"); + }); + + it("returns empty string for unknown steps", () => { + expect(buildStepPrompt("nope")).toBe(""); }); }); diff --git a/src/__tests__/registries.test.ts b/src/__tests__/registries.test.ts index b158e27..740ae1c 100644 --- a/src/__tests__/registries.test.ts +++ b/src/__tests__/registries.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test"; import { buildRulesPrompt, loadRules } from "../rules/registry"; -import { buildSkillsPrompt, loadSkills } from "../skills/registry"; +import { + buildSkillsPrompt, + filterSkillsForStep, + getSkillContent, + loadSkills, +} from "../skills/registry"; describe("Skills Registry", () => { it("should load skills from src directory", () => { @@ -9,7 +14,7 @@ describe("Skills Registry", () => { expect(Array.isArray(skills)).toBe(true); }); - it("should build skills prompt", () => { + it("builds a lightweight manifest (name + description, no body)", () => { const skills = [ { name: "test-skill", @@ -21,13 +26,75 @@ describe("Skills Registry", () => { const prompt = buildSkillsPrompt(skills); expect(prompt).toContain("Available Skills"); expect(prompt).toContain("test-skill"); - expect(prompt).toContain("Test content"); + expect(prompt).toContain("A test skill"); + expect(prompt).toContain("read_skill"); + // the full body is loaded on demand, never injected into the prompt + expect(prompt).not.toContain("Test content"); }); it("should return empty string for no skills", () => { const prompt = buildSkillsPrompt([]); expect(prompt).toBe(""); }); + + it("getSkillContent returns the full body on demand, null when unknown", () => { + const skills = [ + { + name: "test-skill", + description: "A test skill", + triggers: ["test"], + content: "Test content", + }, + ]; + const content = getSkillContent(skills, "test-skill"); + expect(content).toContain("test-skill"); + expect(content).toContain("Test content"); + expect(getSkillContent(skills, "nope")).toBeNull(); + }); +}); + +describe("filterSkillsForStep", () => { + const skill = (name: string) => ({ + name, + description: "", + triggers: [], + content: "", + }); + const skills = [ + skill("code-review"), + skill("obsidian-cli"), + skill("openspec-propose"), + skill("openspec-apply-change"), + skill("openspec-archive-change"), + ]; + const names = (stepId: string) => + filterSkillsForStep(skills, stepId).map((s) => s.name); + + it("always keeps non-openspec skills", () => { + for (const step of ["discuss", "proposal", "apply", "archive", "unknown"]) { + expect(names(step)).toContain("code-review"); + expect(names(step)).toContain("obsidian-cli"); + } + }); + + it("excludes all openspec skills during discuss", () => { + expect(names("discuss")).toEqual(["code-review", "obsidian-cli"]); + }); + + it("keeps only the matching openspec skill per step", () => { + expect(names("proposal")).toContain("openspec-propose"); + expect(names("proposal")).not.toContain("openspec-apply-change"); + expect(names("apply")).toContain("openspec-apply-change"); + expect(names("apply")).not.toContain("openspec-propose"); + expect(names("archive")).toContain("openspec-archive-change"); + expect(names("archive")).not.toContain("openspec-apply-change"); + }); + + it("keeps the propose skill through design, specs and tasks steps", () => { + for (const step of ["design", "specs", "tasks"]) { + expect(names(step)).toContain("openspec-propose"); + } + }); }); describe("Rules Registry", () => { diff --git a/src/__tests__/suggestions.test.ts b/src/__tests__/suggestions.test.ts index f885d04..7021e54 100644 --- a/src/__tests__/suggestions.test.ts +++ b/src/__tests__/suggestions.test.ts @@ -1,5 +1,49 @@ import { describe, expect, it } from "bun:test"; -import { computeSuggestions, getCurrentStepId } from "../services/suggestions"; +import { + computeStepCommands, + computeSuggestions, + getCurrentStepId, + matchStepCommand, +} from "../services/suggestions"; + +const STEPS = ["discuss", "proposal", "design", "specs", "tasks", "apply"]; + +describe("computeStepCommands", () => { + it("lists all step commands for a bare slash", () => { + expect(computeStepCommands("/", STEPS)).toEqual([ + "/discuss", + "/proposal", + "/design", + "/specs", + "/tasks", + "/apply", + ]); + }); + + it("filters by prefix", () => { + expect(computeStepCommands("/pro", STEPS)).toEqual(["/proposal"]); + expect(computeStepCommands("/t", STEPS)).toEqual(["/tasks"]); + }); +}); + +describe("matchStepCommand", () => { + it("resolves an exact command", () => { + expect(matchStepCommand("/apply", STEPS)).toBe("apply"); + expect(matchStepCommand(" /discuss ", STEPS)).toBe("discuss"); + }); + + it("resolves a unique prefix", () => { + expect(matchStepCommand("/propose", STEPS)).toBe("proposal"); + expect(matchStepCommand("/ap", STEPS)).toBe("apply"); + }); + + it("returns null for ambiguous, unknown or non-commands", () => { + expect(matchStepCommand("/d", STEPS)).toBeNull(); // discuss + design + expect(matchStepCommand("/nope", STEPS)).toBeNull(); + expect(matchStepCommand("hello", STEPS)).toBeNull(); + expect(matchStepCommand("/", STEPS)).toBeNull(); + }); +}); describe("computeSuggestions", () => { it("should return discuss suggestions for empty prompt", () => { @@ -19,8 +63,8 @@ describe("computeSuggestions", () => { expect(suggestions).toHaveLength(0); }); - it("should return propose suggestions for propose step", () => { - const suggestions = computeSuggestions("", "propose"); + it("should return proposal suggestions for proposal step", () => { + const suggestions = computeSuggestions("", "proposal"); expect(suggestions.length).toBeGreaterThan(0); expect(suggestions.some((s) => s.includes("proposal"))).toBe(true); }); @@ -28,9 +72,9 @@ describe("computeSuggestions", () => { it("should return all suggestions for empty prompt regardless of step", () => { for (const step of [ "discuss", - "propose", - "plan", + "proposal", "design", + "specs", "tasks", "apply", "archive", diff --git a/src/services/openspec-hook.ts b/src/services/openspec-hook.ts index 8b4197a..82441f6 100644 --- a/src/services/openspec-hook.ts +++ b/src/services/openspec-hook.ts @@ -1,68 +1,156 @@ -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { execSync } from "node:child_process"; import type { Step } from "../types"; -const WORKFLOW_STEPS = [ - { id: "discuss", name: "Discuss" }, - { id: "propose", name: "Propose" }, - { id: "plan", name: "Plan" }, - { id: "design", name: "Design" }, - { id: "tasks", name: "Tasks" }, - { id: "apply", name: "Apply" }, - { id: "archive", name: "Archive" }, -]; +// The workflow always opens with a non-OpenSpec "discuss" step (free-form +// exploration). The remaining steps mirror the real OpenSpec lifecycle: the +// per-change artifacts reported by `openspec status`, then implementation +// (apply) and archival. +const DISCUSS_STEP = "discuss"; +const POST_ARTIFACT_STEPS = ["apply", "archive"] as const; -export function detectOpenSpecSteps(projectDir: string): Step[] { - const changesDir = join(projectDir, "openspec", "changes"); +// Fallback artifact order used only when no active change exists yet, so the +// frise can still show the workflow ahead. The real order comes from the CLI. +const DEFAULT_ARTIFACTS = ["proposal", "design", "specs", "tasks"]; - if (!existsSync(changesDir)) { - return WORKFLOW_STEPS.map((s, i) => ({ - ...s, - status: (i === 0 ? "current" : "pending") as Step["status"], - })); - } +const STEP_NAMES: Record = { + discuss: "Discuss", + proposal: "Proposal", + design: "Design", + specs: "Specs", + tasks: "Tasks", + apply: "Apply", + archive: "Archive", +}; + +const STEP_GUIDANCE: Record = { + discuss: + "Free-form discussion. Explore the problem space with the user — constraints, tradeoffs, prior art, edge cases. Do NOT create any OpenSpec artifacts or run any openspec commands yet.", + proposal: + "Create an OpenSpec change: run `openspec new change `, then write its `proposal.md` (Why / What Changes / Impact).", + design: + "Write the technical design in the change's `design.md`: architecture, data model, API, integration points, alternatives considered.", + specs: + "Write the spec deltas under the change's `specs//spec.md` — the requirements this change adds or modifies. Run `openspec validate ` to check them.", + tasks: + "Break the change into an ordered checklist in its `tasks.md` (`- [ ]` items, grouped, parallelizable work flagged).", + apply: + "Implement the tasks from the change's `tasks.md`, checking them off (`- [x]`) as you complete them. Validate with `openspec validate `.", + archive: + "The change is complete: archive it with `openspec archive --yes`, which merges its spec deltas into `openspec/specs/`. Summarize what was learned.", +}; + +export function buildStepPrompt(stepId: string): string { + const guidance = STEP_GUIDANCE[stepId]; + if (!guidance) return ""; + return `\n## Workflow\n\nActive step: **${stepId}**. ${guidance}\n`; +} + +function stepName(id: string): string { + return STEP_NAMES[id] ?? id.charAt(0).toUpperCase() + id.slice(1); +} + +interface ChangeSummary { + name: string; + completedTasks: number; + totalTasks: number; + lastModified: string; + status: string; +} - const changes = readdirSync(changesDir).filter((f) => !f.startsWith(".")); - if (changes.length === 0) { - return WORKFLOW_STEPS.map((s, i) => ({ - ...s, - status: (i === 0 ? "current" : "pending") as Step["status"], - })); +interface ArtifactStatus { + id: string; + status: string; +} + +function runOpenSpecJson(args: string[], cwd: string): T | null { + try { + const out = execSync(`openspec ${args.join(" ")}`, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + return JSON.parse(out) as T; + } catch { + // CLI missing, not an OpenSpec project, or malformed output + return null; } +} - const latestChange = join(changesDir, changes[changes.length - 1] ?? ""); - const hasProposal = existsSync(join(latestChange, "proposal.md")); - const hasSpecs = existsSync(join(latestChange, "specs")); - const hasDesign = existsSync(join(latestChange, "design.md")); - const hasTasks = existsSync(join(latestChange, "tasks.md")); - - return [ - { - id: "discuss", - name: "Discuss", - status: hasProposal ? "done" : "current", - }, - { - id: "propose", - name: "Propose", - status: hasProposal ? "done" : hasSpecs ? "current" : "pending", - }, - { - id: "plan", - name: "Plan", - status: hasSpecs ? "done" : hasProposal ? "current" : "pending", - }, - { - id: "design", - name: "Design", - status: hasDesign ? "done" : hasSpecs ? "current" : "pending", - }, - { - id: "tasks", - name: "Tasks", - status: hasTasks ? "done" : hasDesign ? "current" : "pending", - }, - { id: "apply", name: "Apply", status: "pending" }, - { id: "archive", name: "Archive", status: "pending" }, +// The change currently being worked on: the most recently modified in-progress +// change, or failing that the most recently modified change overall. +function pickActiveChange(changes: ChangeSummary[]): ChangeSummary | null { + if (changes.length === 0) return null; + const byRecency = [...changes].sort((a, b) => + b.lastModified.localeCompare(a.lastModified), + ); + return ( + byRecency.find((c) => c.status !== "complete" && c.status !== "archived") ?? + byRecency[0] ?? + null + ); +} + +// Each step keeps its true done/not-done state (OpenSpec artifacts aren't +// strictly sequential — tasks.md may exist before specs). The *earliest* +// not-done step is highlighted as "current" (what to work on next); any later +// not-done steps are "pending". +function sequenceSteps(ids: string[], isDone: (id: string) => boolean): Step[] { + let foundCurrent = false; + return ids.map((id) => { + let status: Step["status"]; + if (isDone(id)) { + status = "done"; + } else if (!foundCurrent) { + status = "current"; + foundCurrent = true; + } else { + status = "pending"; + } + return { id, name: stepName(id), status }; + }); +} + +export function detectOpenSpecSteps(projectDir: string): Step[] { + const discussOnly: Step[] = [ + { id: DISCUSS_STEP, name: stepName(DISCUSS_STEP), status: "current" }, ]; + + const list = runOpenSpecJson<{ changes: ChangeSummary[] }>( + ["list", "--json"], + projectDir, + ); + // No CLI or not an OpenSpec project: chat-only, just the discuss step. + if (!list) return discussOnly; + + const active = pickActiveChange(list.changes); + + // OpenSpec project but no active change yet: discuss is current, the rest of + // the workflow shows ahead as pending. + if (!active) { + const ids = [DISCUSS_STEP, ...DEFAULT_ARTIFACTS, ...POST_ARTIFACT_STEPS]; + return sequenceSteps(ids, () => false); + } + + const status = runOpenSpecJson<{ artifacts: ArtifactStatus[] }>( + ["status", "--change", active.name, "--json"], + projectDir, + ); + const artifacts = status?.artifacts ?? []; + const artifactIds = + artifacts.length > 0 ? artifacts.map((a) => a.id) : DEFAULT_ARTIFACTS; + const artifactDone = new Map( + artifacts.map((a) => [a.id, a.status === "done"]), + ); + + const tasksImplemented = + active.totalTasks > 0 && active.completedTasks >= active.totalTasks; + + const ids = [DISCUSS_STEP, ...artifactIds, ...POST_ARTIFACT_STEPS]; + return sequenceSteps(ids, (id) => { + if (id === DISCUSS_STEP) return true; // a change exists, discussion is past + if (id === "apply") return tasksImplemented; + if (id === "archive") return false; // archived changes drop off the list + return artifactDone.get(id) ?? false; + }); } diff --git a/src/services/suggestions.ts b/src/services/suggestions.ts index 7e12801..294dffe 100644 --- a/src/services/suggestions.ts +++ b/src/services/suggestions.ts @@ -6,20 +6,13 @@ const STEP_SUGGESTIONS: Record = { "What are the constraints?", "What are the edge cases?", ], - propose: [ + proposal: [ "Create a proposal for", "I want to add", "I need to change", "The problem is", "We should refactor", ], - plan: [ - "Break this down into steps", - "What files need to change?", - "What's the impact on the codebase?", - "What are the dependencies?", - "List the affected modules", - ], design: [ "Design the API for", "What's the data model?", @@ -27,6 +20,13 @@ const STEP_SUGGESTIONS: Record = { "What's the interface?", "Sketch the architecture", ], + specs: [ + "Write the spec for", + "What are the requirements?", + "Define the behavior of", + "What are the acceptance criteria?", + "Validate the spec", + ], tasks: [ "Create tasks for", "What's the priority order?", @@ -65,3 +65,35 @@ export function getCurrentStepId( const current = steps.find((s) => s.status === "current"); return current?.id ?? "discuss"; } + +// Slash-command autocompletions for switching workflow steps, e.g. typing +// "/pro" suggests ["/proposal"]. Driven by the live frise so the commands +// always match the steps actually on screen. +export function computeStepCommands( + prompt: string, + stepIds: string[], +): string[] { + const lower = prompt.toLowerCase(); + return stepIds.map((id) => `/${id}`).filter((cmd) => cmd.startsWith(lower)); +} + +// Natural-language shorthands that aren't a prefix of the canonical step id. +const STEP_ALIASES: Record = { propose: "proposal" }; + +// Resolve a prompt like "/apply" (an alias like "/propose", or a unique prefix +// like "/pro") to a step id, or null when it isn't a step command, the alias +// doesn't apply, or the prefix is ambiguous. +export function matchStepCommand( + prompt: string, + stepIds: string[], +): string | null { + const trimmed = prompt.trim().toLowerCase(); + if (!trimmed.startsWith("/")) return null; + const query = trimmed.slice(1); + if (!query) return null; + if (stepIds.includes(query)) return query; + const alias = STEP_ALIASES[query]; + if (alias && stepIds.includes(alias)) return alias; + const matches = stepIds.filter((id) => id.startsWith(query)); + return matches.length === 1 ? (matches[0] ?? null) : null; +} diff --git a/src/skills/registry.ts b/src/skills/registry.ts index 7608915..85e4506 100644 --- a/src/skills/registry.ts +++ b/src/skills/registry.ts @@ -95,15 +95,52 @@ function parseSkillFile(content: string): Skill | null { return { name, description, triggers, content: body.trim() }; } +// Skills whose name contains "openspec" are step-specific: they are only +// injected when the active workflow step matches one of these keywords. +// All other skills are always available. +// The proposal → design → specs → tasks artifacts are all authored by the +// "propose" family of skills, so they stay available across those steps. +const OPENSPEC_STEP_KEYWORDS: Record = { + discuss: [], + proposal: ["propose", "proposal"], + design: ["propose", "proposal", "design"], + specs: ["propose", "proposal", "spec"], + tasks: ["propose", "proposal", "task"], + apply: ["apply"], + archive: ["archive"], +}; + +export function filterSkillsForStep(skills: Skill[], stepId: string): Skill[] { + const keywords = OPENSPEC_STEP_KEYWORDS[stepId] ?? []; + return skills.filter((skill) => { + const name = skill.name.toLowerCase(); + if (!name.includes("openspec")) return true; + return keywords.some((k) => name.includes(k)); + }); +} + +// A manifest only: names + one-line descriptions. Injecting every SKILL.md +// body cost ~3000 tokens and was re-evaluated on every turn; instead the model +// loads a skill's full instructions on demand with the `read_skill` tool. export function buildSkillsPrompt(skills: Skill[]): string { if (skills.length === 0) return ""; - let prompt = "\n## Available Skills\n\n"; + let prompt = + "\n## Available Skills\n\n" + + "When a task matches one of these skills, call the `read_skill` tool with " + + "its name to load the full instructions before proceeding.\n\n"; for (const skill of skills) { - prompt += `### ${skill.name}\n`; - prompt += `${skill.description}\n\n`; - prompt += `${skill.content}\n\n`; + prompt += `- \`${skill.name}\`: ${skill.description}\n`; } + prompt += "\n"; return prompt; } + +// Full instructions for a single skill, loaded on demand by the read_skill +// tool. Returns null when no skill by that name is available. +export function getSkillContent(skills: Skill[], name: string): string | null { + const skill = skills.find((s) => s.name === name); + if (!skill) return null; + return `# ${skill.name}\n\n${skill.description}\n\n${skill.content}`; +} diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 1bfa532..8ba4e85 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -2,12 +2,29 @@ import { Box, useApp, useInput } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; import { buildRulesPrompt, loadRules } from "../rules/registry"; import { streamChat } from "../services/dmr-client"; -import { fetchModels, nextModel } from "../services/model-registry"; -import { detectOpenSpecSteps } from "../services/openspec-hook"; -import { computeSuggestions, getCurrentStepId } from "../services/suggestions"; +import { fetchModels, nextModel, prevModel } from "../services/model-registry"; +import { + buildStepPrompt, + detectOpenSpecSteps, +} from "../services/openspec-hook"; +import { + computeStepCommands, + computeSuggestions, + matchStepCommand, +} from "../services/suggestions"; import { hasThinkingSupport } from "../services/thinking-parser"; -import { buildSkillsPrompt, loadSkills } from "../skills/registry"; -import { getSavedTheme, saveTheme } from "../state/config"; +import { + buildSkillsPrompt, + filterSkillsForStep, + loadSkills, + type Skill, +} from "../skills/registry"; +import { + getSavedTheme, + getThinkingEnabled, + saveTheme, + saveThinkingEnabled, +} from "../state/config"; import { createSessionId, saveSession } from "../state/session"; import { executeTool } from "../tools/executor"; import { loadTools } from "../tools/registry"; @@ -33,6 +50,39 @@ import { getThemeNames, setThemeByName } from "./theme"; const MAX_TOOL_ITERATIONS = 10; +function stepIdAt(steps: Step[], index: number): string { + return steps[index]?.id ?? "discuss"; +} + +// Suggestions are step-command completions ("/apply") when the prompt starts +// with a slash, otherwise the free-form prompt starters for the active step. +function computePromptSuggestions( + text: string, + stepId: string, + steps: Step[], +): string[] { + if (text.startsWith("/")) { + return computeStepCommands( + text, + steps.map((s) => s.id), + ); + } + return computeSuggestions(text, stepId); +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +// The thinking pane is on screen when it is enabled and there is either +// captured reasoning or a live thinking stream in progress. +function isThinkingShown(s: AppState): boolean { + return ( + s.thinkingVisible && + (s.thinking.content !== "" || (s.streaming && s.thinking.active)) + ); +} + type UIMode = "normal" | "palette"; interface AppState { @@ -43,7 +93,10 @@ interface AppState { thinking: { active: boolean; content: string; tokens: number }; result: string; scrollOffset: number; + thinkingScrollOffset: number; + scrollFocus: "thinking" | "response"; thinkingVisible: boolean; + thinkingEnabled: boolean; streaming: boolean; agentSteps: AgentStep[]; uiMode: UIMode; @@ -57,7 +110,6 @@ interface AppState { export function App() { const { exit } = useApp(); - const layout = useLayout(); const [state, setState] = useState({ model: null, @@ -67,7 +119,10 @@ export function App() { thinking: { active: false, content: "", tokens: 0 }, result: "", scrollOffset: 0, + thinkingScrollOffset: 0, + scrollFocus: "response", thinkingVisible: true, + thinkingEnabled: false, streaming: false, agentSteps: [], uiMode: "normal", @@ -84,7 +139,8 @@ export function App() { const streamAbortRef = useRef(null); const cwd = useRef(process.cwd()); const sessionId = useRef(createSessionId()); - const systemPromptRef = useRef(""); + const skillsRef = useRef([]); + const rulesPromptRef = useRef(""); const toolsRef = useRef([]); // Initialize @@ -92,6 +148,7 @@ export function App() { async function init() { const savedTheme = getSavedTheme(); setThemeByName(savedTheme); + const thinkingEnabled = getThinkingEnabled(); const models = await fetchModels(); if (models.length === 0) { @@ -103,22 +160,28 @@ export function App() { } const steps = detectOpenSpecSteps(cwd.current); - const stepId = getCurrentStepId(steps); - const initialSuggestions = computeSuggestions("", stepId); + // Select the filesystem-detected current step by default + const currentIdx = Math.max( + 0, + steps.findIndex((st) => st.status === "current"), + ); + const initialSuggestions = computeSuggestions( + "", + stepIdAt(steps, currentIdx), + ); - const skills = loadSkills(cwd.current); - const rules = loadRules(); - const tools = loadTools(cwd.current); - toolsRef.current = tools; - systemPromptRef.current = - buildSkillsPrompt(skills) + buildRulesPrompt(rules); + skillsRef.current = loadSkills(cwd.current); + rulesPromptRef.current = buildRulesPrompt(loadRules()); + toolsRef.current = loadTools(cwd.current); setState((s) => ({ ...s, models, model: models[0] ?? null, steps, + selectedStepIndex: currentIdx, suggestions: initialSuggestions, + thinkingEnabled, })); } void init(); @@ -131,6 +194,23 @@ export function App() { const handleSubmit = useCallback(async () => { if (!prompt.trim() || !state.model || state.streaming) return; + // A "/step" command switches the workflow step instead of prompting the LLM + const cmdStep = matchStepCommand( + prompt, + state.steps.map((s) => s.id), + ); + if (cmdStep) { + const idx = state.steps.findIndex((s) => s.id === cmdStep); + setPrompt(""); + setCursorPos(0); + update({ + selectedStepIndex: Math.max(0, idx), + suggestions: computeSuggestions("", cmdStep), + suggestionIndex: -1, + }); + return; + } + const userPrompt = prompt; setPrompt(""); setCursorPos(0); @@ -144,22 +224,35 @@ export function App() { update({ streaming: true, result: "", + thinking: { active: false, content: "", tokens: 0 }, agentSteps: [], suggestions: [], scrollOffset: 0, + thinkingScrollOffset: 0, + scrollFocus: "response", messages: [...state.messages, userMessage], }); const model = state.model; if (!model) return; - const useThinking = hasThinkingSupport(model.id); + const modelSupportsThinking = hasThinkingSupport(model.id); + const useThinking = modelSupportsThinking && state.thinkingEnabled; + const disableThinking = modelSupportsThinking && !state.thinkingEnabled; + + // Build the system prompt for the active step: only matching openspec + // skills are injected, plus step-specific workflow guidance + const stepId = stepIdAt(state.steps, state.selectedStepIndex); + const systemPrompt = + buildSkillsPrompt(filterSkillsForStep(skillsRef.current, stepId)) + + rulesPromptRef.current + + buildStepPrompt(stepId); const messages: { role: string; content: string; tool_call_id?: string; name?: string; - }[] = [{ role: "system", content: systemPromptRef.current }]; + }[] = [{ role: "system", content: systemPrompt }]; for (const msg of [...state.messages, userMessage]) { messages.push({ role: msg.role, content: msg.content }); @@ -175,6 +268,7 @@ export function App() { cwd.current, update, streamAbortRef.current.signal, + disableThinking, ); } catch (err) { if (streamAbortRef.current?.signal.aborted) { @@ -214,7 +308,21 @@ export function App() { // Silent fail } - return { ...s, streaming: false, messages: newMessages }; + // The turn may have created or advanced an OpenSpec change: refresh the + // frise, keeping the user's manual selection (clamped to the new length). + const steps = detectOpenSpecSteps(cwd.current); + const selectedStepIndex = Math.min( + s.selectedStepIndex, + Math.max(0, steps.length - 1), + ); + + return { + ...s, + streaming: false, + messages: newMessages, + steps, + selectedStepIndex, + }; }); }, [ prompt, @@ -222,6 +330,9 @@ export function App() { state.streaming, state.messages, state.result, + state.steps, + state.selectedStepIndex, + state.thinkingEnabled, update, ]); @@ -236,6 +347,25 @@ export function App() { exit(); }, [exit]); + const selectStep = useCallback( + (index: number) => { + setState((s) => { + const clamped = Math.max(0, Math.min(s.steps.length - 1, index)); + return { + ...s, + selectedStepIndex: clamped, + suggestions: computePromptSuggestions( + prompt, + stepIdAt(s.steps, clamped), + s.steps, + ), + suggestionIndex: -1, + }; + }); + }, + [prompt], + ); + // Actions for palette const themeActions: Action[] = getThemeNames().map((name) => ({ id: `theme-${name}`, @@ -255,40 +385,51 @@ export function App() { shortcut: "Tab", action: () => { const newModel = nextModel(); - if (newModel) { - update({ - model: newModel, - steps: detectOpenSpecSteps(cwd.current), - }); - } + if (newModel) update({ model: newModel }); + }, + }, + { + id: "refresh-steps", + label: "Refresh Workflow", + shortcut: "", + action: () => { + const steps = detectOpenSpecSteps(cwd.current); + update({ + steps, + selectedStepIndex: Math.min( + state.selectedStepIndex, + Math.max(0, steps.length - 1), + ), + }); + }, + }, + { + id: "thinking-mode", + label: `Model Thinking: ${state.thinkingEnabled ? "on" : "off"}`, + shortcut: "", + action: () => { + const enabled = !state.thinkingEnabled; + saveThinkingEnabled(enabled); + update({ thinkingEnabled: enabled }); }, }, { id: "thinking", - label: "Toggle Thinking", + label: "Toggle Thinking Pane", shortcut: "", action: () => update({ thinkingVisible: !state.thinkingVisible }), }, { id: "step-prev", label: "Step: Previous", - shortcut: "", - action: () => - update({ - selectedStepIndex: Math.max(0, state.selectedStepIndex - 1), - }), + shortcut: "alt+←", + action: () => selectStep(state.selectedStepIndex - 1), }, { id: "step-next", label: "Step: Next", - shortcut: "", - action: () => - update({ - selectedStepIndex: Math.min( - state.steps.length - 1, - state.selectedStepIndex + 1, - ), - }), + shortcut: "alt+→", + action: () => selectStep(state.selectedStepIndex + 1), }, ...themeActions, { @@ -301,6 +442,8 @@ export function App() { thinking: { active: false, content: "", tokens: 0 }, agentSteps: [], scrollOffset: 0, + thinkingScrollOffset: 0, + scrollFocus: "response", }), }, { @@ -378,17 +521,63 @@ export function App() { return; } - // Scroll + // Scroll focus: the thinking pane is only a scroll target when on screen. + const thinkingShown = isThinkingShown(s); + const focus = thinkingShown ? s.scrollFocus : "response"; + + // Ctrl+T — move the scroll focus between the thinking and response panes + if (key.ctrl && input === "t") { + if (thinkingShown) { + update({ + scrollFocus: s.scrollFocus === "thinking" ? "response" : "thinking", + }); + } + return; + } + + // Scroll the focused pane — offset counts from the bottom (0 = follow tail) + const scrollBy = (delta: number) => { + if (focus === "thinking") { + const lineCount = s.thinking.content + ? s.thinking.content.split("\n").length + : 0; + update({ + thinkingScrollOffset: clamp( + s.thinkingScrollOffset + delta, + 0, + lineCount, + ), + }); + } else { + const lineCount = s.result ? s.result.split("\n").length : 0; + update({ scrollOffset: clamp(s.scrollOffset + delta, 0, lineCount) }); + } + }; if (key.pageUp || (key.upArrow && key.shift)) { - update({ scrollOffset: Math.max(0, s.scrollOffset - 5) }); + scrollBy(5); return; } if (key.pageDown || (key.downArrow && key.shift)) { - update({ scrollOffset: s.scrollOffset + 5 }); + scrollBy(-5); + return; + } + if (key.upArrow) { + scrollBy(1); + return; + } + if (key.downArrow) { + scrollBy(-1); return; } - // Tab = accept suggestion + // Shift+Tab = previous model + if (key.tab && key.shift) { + const newModel = prevModel(); + if (newModel) update({ model: newModel }); + return; + } + + // Tab = accept suggestion, or next model when there is none if (key.tab) { if (s.suggestions.length > 0 && s.uiMode === "normal") { const idx = s.suggestionIndex >= 0 ? s.suggestionIndex : 0; @@ -397,6 +586,9 @@ export function App() { setPrompt(suggestion); setCursorPos(suggestion.length); } + } else { + const newModel = nextModel(); + if (newModel) update({ model: newModel }); } return; } @@ -407,6 +599,16 @@ export function App() { return; } + // Alt+←/→ — navigate workflow steps + if (key.meta && key.leftArrow) { + selectStep(s.selectedStepIndex - 1); + return; + } + if (key.meta && key.rightArrow) { + selectStep(s.selectedStepIndex + 1); + return; + } + // Arrow keys if (key.leftArrow) { setCursorPos((p) => Math.max(0, p - 1)); @@ -419,17 +621,19 @@ export function App() { // Prompt editing if (key.backspace) { - setPrompt((p) => p.slice(0, cursorPos - 1) + p.slice(cursorPos)); + const next = prompt.slice(0, cursorPos - 1) + prompt.slice(cursorPos); + setPrompt(next); setCursorPos((p) => Math.max(0, p - 1)); - const stepId = getCurrentStepId(s.steps); - const suggestions = computeSuggestions(prompt, stepId); + const stepId = stepIdAt(s.steps, s.selectedStepIndex); + const suggestions = computePromptSuggestions(next, stepId, s.steps); update({ suggestions, suggestionIndex: -1 }); return; } if (key.delete) { - setPrompt((p) => p.slice(0, cursorPos) + p.slice(cursorPos + 1)); - const stepId = getCurrentStepId(s.steps); - const suggestions = computeSuggestions(prompt, stepId); + const next = prompt.slice(0, cursorPos) + prompt.slice(cursorPos + 1); + setPrompt(next); + const stepId = stepIdAt(s.steps, s.selectedStepIndex); + const suggestions = computePromptSuggestions(next, stepId, s.steps); update({ suggestions, suggestionIndex: -1 }); return; } @@ -456,32 +660,42 @@ export function App() { // Regular character if (input && !key.ctrl && !key.meta) { - setPrompt((p) => p.slice(0, cursorPos) + input + p.slice(cursorPos)); + const next = prompt.slice(0, cursorPos) + input + prompt.slice(cursorPos); + setPrompt(next); setCursorPos((p) => p + input.length); - const stepId = getCurrentStepId(s.steps); - const suggestions = computeSuggestions( - prompt.slice(0, cursorPos) + input + prompt.slice(cursorPos), - stepId, - ); + const stepId = stepIdAt(s.steps, s.selectedStepIndex); + const suggestions = computePromptSuggestions(next, stepId, s.steps); update({ suggestions, suggestionIndex: -1 }); } }); + const thinkingActive = state.streaming && state.thinking.active; + const showThinking = isThinkingShown(state); + const showAgent = state.agentSteps.length > 0; + const scrollFocus = showThinking ? state.scrollFocus : "response"; + const layout = useLayout({ showThinking, showAgent }); + return ( -
+
0} + focused={scrollFocus === "response"} /> {state.uiMode === "palette" && ( @@ -517,6 +734,7 @@ async function runAgentLoop( projectDir: string, update: (partial: Partial) => void, signal?: AbortSignal, + disableThinking?: boolean, ) { let iterations = 0; @@ -530,7 +748,13 @@ async function runAgentLoop( { id: string; name: string; argsBuffer: string } >(); - const stream = streamChat(messages, modelId, tools, signal); + const stream = streamChat( + messages, + modelId, + tools, + signal, + disableThinking, + ); if (useThinking) { for await (const chunk of stream) { diff --git a/src/tui/components/AgentSteps.tsx b/src/tui/components/AgentSteps.tsx index f982953..194d2e7 100644 --- a/src/tui/components/AgentSteps.tsx +++ b/src/tui/components/AgentSteps.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type { AgentStep } from "../../types"; +import { useSpinner } from "../hooks/use-spinner"; import { symbols, t } from "../theme"; const TOOL_ICONS: Record = { @@ -17,9 +18,14 @@ interface AgentStepsProps { } export function AgentSteps({ steps, maxHeight }: AgentStepsProps) { + const running = steps.some((s) => s.status === "running"); + const spinner = useSpinner(running); + if (steps.length === 0 || maxHeight < 2) return null; - const visibleSteps = steps.slice(0, Math.max(1, maxHeight - 2)); + // Show the most recent steps so activity stays visible + const visibleSteps = steps.slice(-Math.max(1, maxHeight - 2)); + const doneCount = steps.filter((s) => s.status === "done").length; return ( - ── agent ── + ── agent · {doneCount}/{steps.length} ── {visibleSteps.map((step) => { const icon = getStepIcon(step); const text = getStepText(step); - const indicator = getStepIndicator(step); + const indicator = + step.status === "running" ? spinner : getStepIndicator(step); const color = getStepColor(step); return ( - + {icon} {text} {indicator} ); @@ -75,12 +82,6 @@ function getStepText(step: AgentStep): string { function getStepIndicator(step: AgentStep): string { switch (step.status) { - case "running": - return ( - symbols.spinner[ - Math.floor(Date.now() / 100) % symbols.spinner.length - ] ?? "" - ); case "done": return symbols.check; case "error": diff --git a/src/tui/components/CommandPalette.tsx b/src/tui/components/CommandPalette.tsx index b6abfb4..05de10d 100644 --- a/src/tui/components/CommandPalette.tsx +++ b/src/tui/components/CommandPalette.tsx @@ -1,7 +1,10 @@ import { Box, Text } from "ink"; import type { Action } from "../../types"; +import { useTerminalSize } from "../hooks/use-terminal"; import { t } from "../theme"; +const PALETTE_WIDTH = 40; + interface CommandPaletteProps { actions: Action[]; selectedIndex: number; @@ -11,35 +14,49 @@ export function CommandPalette({ actions, selectedIndex, }: CommandPaletteProps) { + const { columns, rows } = useTerminalSize(); + const height = actions.length + 4; + const left = Math.max(0, Math.floor((columns - PALETTE_WIDTH) / 2)); + const top = Math.max(0, Math.floor((rows - height) / 2)); + return ( - - {" "} - Actions{" "} - - ──────────── + + + Actions + + ↑↓ · enter · esc + + {"─".repeat(PALETTE_WIDTH - 4)} {actions.map((action, i) => { const isSelected = i === selectedIndex; return ( - - - {isSelected ? "❯ " : " "} - - - {action.label} + + + + {isSelected ? "❯ " : " "} + + + {action.label} + - {action.shortcut && ( - {action.shortcut} - )} - + {action.shortcut ? ( + {action.shortcut} + ) : null} + ); })} diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx index 050d374..a76a338 100644 --- a/src/tui/components/Header.tsx +++ b/src/tui/components/Header.tsx @@ -1,21 +1,39 @@ +import { basename } from "node:path"; import { Box, Text } from "ink"; import type { Model } from "../../types"; import { symbols, t } from "../theme"; interface HeaderProps { model: Model | null; + modelCount: number; } -export function Header({ model }: HeaderProps) { +export function Header({ model, modelCount }: HeaderProps) { return ( - - - 🏄 paikea - - - - {symbols.gear} {model?.name ?? "none"} - + + + + + 🏄 paikea + + {symbols.divider} + + {symbols.gear} {model?.name ?? "none"} + + {modelCount > 1 && ( + ({modelCount} models) + )} + + {basename(process.cwd())} + + {/* horizon line — the sea/sky boundary under the masthead */} + ); } diff --git a/src/tui/components/PromptInput.tsx b/src/tui/components/PromptInput.tsx index a9141de..b860c91 100644 --- a/src/tui/components/PromptInput.tsx +++ b/src/tui/components/PromptInput.tsx @@ -7,29 +7,41 @@ interface PromptInputProps { focused: boolean; suggestions: string[]; suggestionIndex: number; + disabled: boolean; } export function PromptInput({ prompt, - cursorPos: _cursorPos, + cursorPos, focused, suggestions, suggestionIndex, + disabled, }: PromptInputProps) { - const lineColor = focused ? t().fg.accent : t().fg.dim; + const lineColor = focused && !disabled ? t().fg.accent : t().fg.dim; return ( - + ❯{" "} {prompt ? ( - {prompt} + ) : ( - type your prompt... + <> + + {disabled + ? "generating… (esc to cancel)" + : "cast off — set a course…"} + + {focused && !disabled && █} + )} - {focused && █} {(() => { @@ -37,22 +49,52 @@ export function PromptInput({ suggestionIndex >= 0 ? suggestions[suggestionIndex] : suggestions[0]; - if (suggestion && focused) { - const remaining = suggestion.slice(prompt.length); + if (suggestion && focused && !disabled) { + const remaining = suggestion.startsWith(prompt) + ? suggestion.slice(prompt.length) + : suggestion; return ( <> {remaining && ( - + {remaining} )} - tab → + ⇥ tab ); } - return Enter to send; + return ( + {disabled ? " " : "enter to send"} + ); })()} ); } + +function PromptText({ + prompt, + cursorPos, + showCursor, +}: { + prompt: string; + cursorPos: number; + showCursor: boolean; +}) { + if (!showCursor) { + return {prompt}; + } + + const before = prompt.slice(0, cursorPos); + const at = prompt[cursorPos] ?? " "; + const after = prompt.slice(cursorPos + 1); + + return ( + + {before} + {at} + {after} + + ); +} diff --git a/src/tui/components/ResultPane.tsx b/src/tui/components/ResultPane.tsx index 49a640a..48a853a 100644 --- a/src/tui/components/ResultPane.tsx +++ b/src/tui/components/ResultPane.tsx @@ -1,68 +1,116 @@ import { Box, Text } from "ink"; +import { useSpinner } from "../hooks/use-spinner"; import { symbols, t } from "../theme"; interface ResultPaneProps { content: string; scrollOffset: number; maxHeight: number; + streaming: boolean; + hasHistory: boolean; + focused: boolean; } export function ResultPane({ content, scrollOffset, maxHeight, + streaming, + hasHistory, + focused, }: ResultPaneProps) { const height = Math.max(3, maxHeight); + const spinner = useSpinner(streaming && !content); + const maxLines = height - 2; + + const lines = content ? content.split("\n") : []; + // scrollOffset counts from the bottom: 0 = follow the tail + const clampedOffset = Math.min( + scrollOffset, + Math.max(0, lines.length - maxLines), + ); + const start = Math.max(0, lines.length - maxLines - clampedOffset); + const end = start + maxLines; + const overflow = lines.length > maxLines; + + const title = overflow + ? `── response · ${start + 1}–${Math.min(end, lines.length)}/${lines.length} ──` + : "── response ──"; return ( - - ── response ── - + + + {title} + + {overflow && clampedOffset > 0 && ( + ▼ shift+↓ latest + )} + {content ? ( - + + ) : streaming ? ( + {spinner} awaiting response... ) : ( - - { - symbols.spinner[ - Math.floor(Date.now() / 100) % symbols.spinner.length - ] - }{" "} - awaiting response... - + )} ); } -function ResultContent({ - content, - scrollOffset, +function Welcome({ + hasHistory, maxLines, }: { - content: string; - scrollOffset: number; + hasHistory: boolean; maxLines: number; }) { - const lines = content.split("\n"); - const visibleLines = lines.slice( - scrollOffset, - scrollOffset + Math.max(1, maxLines), - ); + if (hasHistory) { + return calm seas — awaiting orders; + } + const lines = [ + + {" "}🏄 paikea + , + + {" "}Chat with local LLMs via 🐳 Docker Model Runner + , + , + + {" "}enter{" "}send prompt + , + + {" "}tab{" "}accept suggestion / next model + , + + {" "}ctrl+p{" "}command palette (themes, models…) + , + + {" "}↑/↓{" "}scroll · ctrl+t switches thinking / response + , + + {" "}/step{" "}switch workflow step (/discuss, /proposal, /apply…) + , + ]; + return {lines.slice(0, maxLines)}; +} +function ResultContent({ + lines, + startLine, +}: { + lines: string[]; + startLine: number; +}) { return ( - {visibleLines.map((line, idx) => { + {lines.map((line, idx) => { let displayLine = line; let color = t().fg.primary; let bold = false; @@ -90,8 +138,13 @@ function ResultContent({ } return ( - // biome-ignore lint/suspicious/noArrayIndexKey: flat text list, never reorders - + {displayLine} ); diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index 110490f..803613b 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,48 +1,77 @@ import { Box, Text } from "ink"; import type { Model } from "../../types"; -import { symbols, t } from "../theme"; +import { useElapsed, useSpinner } from "../hooks/use-spinner"; +import { t } from "../theme"; interface StatusBarProps { model: Model | null; + stepName: string | null; tokens: number; streaming: boolean; confirmQuit: boolean; + canFocusPanes: boolean; } +// The whole bar is a single Text with truncate-end so it never wraps to a +// second line and breaks the fixed-height layout on narrow terminals. export function StatusBar({ model, + stepName, tokens, streaming, confirmQuit, + canFocusPanes, }: StatusBarProps) { - return ( - - {confirmQuit ? ( - - {" "} - sure quit? (esc again){" "} - - ) : streaming ? ( - - {" "} - { - symbols.spinner[ - Math.floor(Date.now() / 100) % symbols.spinner.length - ] - }{" "} - streaming{" "} + const spinner = useSpinner(streaming); + const elapsed = useElapsed(streaming); + + if (confirmQuit) { + return ( + + + + {" "} + quit paikea? esc again to confirm{" "} + + · any other key to stay - ) : ( - <> - + + ); + } + + if (streaming) { + return ( + + + {" "} - {symbols.gear} {model?.name ?? "none"} + {spinner} generating - · tokens {tokens} - - ctrl+p palette · esc quit - - )} + + {" "} + · {model?.name ?? "none"} · {elapsed}s + {tokens > 0 ? ` · thinking ${tokens}` : ""} · esc to cancel + + + + ); + } + + return ( + + + {stepName ? ( + · cap {stepName.toLowerCase()} + ) : null} + {tokens > 0 ? ( + · thinking {tokens} + ) : null} + + {" "} + · ↑↓ scroll{canFocusPanes ? " · ctrl+t pane" : ""} · tab model · /step + switch · ctrl+p palette · esc quit + + ); } diff --git a/src/tui/components/ThinkingPane.tsx b/src/tui/components/ThinkingPane.tsx index 66c790e..4843f97 100644 --- a/src/tui/components/ThinkingPane.tsx +++ b/src/tui/components/ThinkingPane.tsx @@ -1,48 +1,67 @@ import { Box, Text } from "ink"; -import { symbols, t } from "../theme"; +import { useSpinner } from "../hooks/use-spinner"; +import { t } from "../theme"; interface ThinkingPaneProps { content: string; + active: boolean; visible: boolean; maxHeight: number; + scrollOffset: number; + focused: boolean; } export function ThinkingPane({ content, + active, visible, maxHeight, + scrollOffset, + focused, }: ThinkingPaneProps) { + const spinner = useSpinner(active && visible); + if (!visible || maxHeight < 2) return null; + if (!content && !active) return null; + + const maxLines = Math.max(1, maxHeight - 2); + const lines = content ? content.split("\n") : []; + // scrollOffset counts from the bottom: 0 = follow the latest thinking + const clampedOffset = Math.min( + scrollOffset, + Math.max(0, lines.length - maxLines), + ); + const start = Math.max(0, lines.length - maxLines - clampedOffset); + const end = start + maxLines; + const overflow = lines.length > maxLines; + const visibleLines = lines.slice(start, end); - const lines = content.split("\n"); - const visibleLines = lines.slice(0, Math.max(1, maxHeight - 2)); + const title = overflow + ? `── thinking ${active ? spinner : "✓"} · ${start + 1}–${Math.min(end, lines.length)}/${lines.length} ──` + : `── thinking ${active ? spinner : "✓"} ──`; return ( - - ── thinking ── - + + + {title} + + {focused && overflow && ↑↓ scroll} + {content ? ( visibleLines.map((line, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: flat text list, never reorders - + {line} )) ) : ( - - { - symbols.spinner[ - Math.floor(Date.now() / 100) % symbols.spinner.length - ] - }{" "} - thinking... - + {spinner} thinking... )} ); diff --git a/src/tui/hooks/use-spinner.ts b/src/tui/hooks/use-spinner.ts new file mode 100644 index 0000000..5ce6630 --- /dev/null +++ b/src/tui/hooks/use-spinner.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { symbols } from "../theme"; + +export function useSpinner(active: boolean): string { + const [frame, setFrame] = useState(0); + + useEffect(() => { + if (!active) return; + const id = setInterval( + () => setFrame((f) => (f + 1) % symbols.spinner.length), + 80, + ); + return () => clearInterval(id); + }, [active]); + + return symbols.spinner[frame] ?? ""; +} + +export function useElapsed(active: boolean): number { + const [start, setStart] = useState(null); + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + if (!active) { + setStart(null); + return; + } + setStart(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [active]); + + if (!active || start === null) return 0; + return Math.floor((now - start) / 1000); +} diff --git a/src/tui/hooks/use-terminal.ts b/src/tui/hooks/use-terminal.ts index 85f62f8..b20426a 100644 --- a/src/tui/hooks/use-terminal.ts +++ b/src/tui/hooks/use-terminal.ts @@ -24,13 +24,22 @@ export function useTerminalSize() { const FIXED_LINES = 9; -export function useLayout() { - const { rows } = useTerminalSize(); +export interface LayoutOptions { + showThinking: boolean; + showAgent: boolean; +} + +export function useLayout({ showThinking, showAgent }: LayoutOptions) { + const { rows, columns } = useTerminalSize(); const contentHeight = Math.max(4, rows - FIXED_LINES); - const thinkingMax = Math.min(Math.floor(contentHeight * 0.25), 8); - const agentMax = Math.min(Math.floor(contentHeight * 0.25), 6); + const thinkingMax = showThinking + ? Math.min(Math.floor(contentHeight * 0.25), 8) + : 0; + const agentMax = showAgent + ? Math.min(Math.floor(contentHeight * 0.25), 6) + : 0; const resultHeight = Math.max(3, contentHeight - thinkingMax - agentMax); - return { contentHeight, thinkingMax, agentMax, resultHeight }; + return { contentHeight, thinkingMax, agentMax, resultHeight, columns, rows }; }