One row of chrome, no banner, and qwen starts thinking low

8502aaa7d9a9 · AtlantisPleb · · parent f65066b3cf2f

One row of chrome, no banner, and qwen starts thinking low

The session opened with four lines of keys and commands, and carried a second
row of keys under the status line for the rest of it. Both are reference
material, and reference material belongs where a reader looks for it once rather
than past it always. `/help` now carries all of it, keys included, and neither
the banner nor the second row is printed.

That leaves one row under the composer, and it is the status line. The scroll
indicator moved onto it, because losing the second row would otherwise have lost
the one thing on it that was state rather than reminder: a still transcript with
nothing saying the reader is scrolled up reads as a stopped session.

A hint that outranked everything else on a narrow row went with the row. It
existed for the key that stops the fleet from spending, and there is no longer a
row for that key to be dropped from, so the ranking it needed is gone rather
than kept as machinery nothing reaches.

`qwen3.8` now starts at low reasoning rather than medium. It thinks at length by
default — one measured turn produced 150,322 characters of reasoning against
8,232 of answer — and on a local machine that is the wall clock. `shift+tab`
raises it for the turn that wants it, which is the right way round: a reader
asks for more thinking when the work needs it rather than paying for it on every
question. Only that family, and `--reasoning` still wins where it is given.

Five tests covered the row that is gone. They are removed rather than adapted,
because what they asserted — that the counter survives a narrow row, that a key
appears only where it does something — is no longer true of anything. Three
replace them: the chrome is one row, the session opens without a banner, and the
scroll state is still reported.

508 tests pass.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/src/coder-ollama.ts
  • modified packages/openagents-cli/src/coder-session.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts

Diff

4 files changed, +135 -174

packages/openagents-cli/src/coder-ollama.ts modified +15 -1

@@ -88,6 +88,18 @@ const FROM_FLAG: Record<string, string> = {

88 88
/** The levels, in the order the interface cycles them. */
89 89
export const OLLAMA_REASONING_LEVELS = Object.keys(THINK);
90 90
91
/**
92
 * Where a model's reasoning starts when nobody says.
93
 *
94
 * `qwen3.8` thinks at length by default — one measured turn produced 150,322
95
 * characters of reasoning against 8,232 of answer — and on a local machine that
96
 * is the wall clock. It starts low, and `shift+tab` raises it for the turn that
97
 * needs it, which is the right way round: the reader asks for more thinking when
98
 * the work wants it rather than waiting for it on every question.
99
 */
100
const defaultReasoningFor = (model: string): string =>
101
  /^qwen3\.8\b/.test(model) ? "low" : "medium";
102
91 103
export interface OllamaOptions {
92 104
  /** The Ollama model name, without the `ollama:` prefix. */
93 105
  readonly model: string;

@@ -277,7 +289,9 @@ export class OllamaReplySource implements ReplySource {

277 289
278 290
  constructor(options: OllamaOptions) {
279 291
    this.reasoningLevel =
280
      options.reasoning === undefined ? "medium" : (FROM_FLAG[options.reasoning] ?? "medium");
292
      options.reasoning === undefined
293
        ? defaultReasoningFor(options.model)
294
        : (FROM_FLAG[options.reasoning] ?? "medium");
281 295
    this.host = options.host ?? DEFAULT_HOST;
282 296
    this.client = new Ollama({ host: this.host });
283 297
    this.modelName = options.model;
packages/openagents-cli/src/coder-session.ts modified +55

@@ -723,6 +723,61 @@ export class CoderSession {

723 723
      return;
724 724
    }
725 725
726
    // `/help` was going to the model, which answered with nothing. The keys and
727
    // the commands are the interface's own facts and it should not have to ask
728
    // anything to state them.
729
    if (/^\/(help|\?)\s*$/.test(prompt.trim())) {
730
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
731
      this.notice(
732
        [
733
          "Commands",
734
          "  /help                       this list",
735
          "  /system                     what the model is told: tools, skills, and its",
736
          "                              standing context",
737
          "  /skills                     choose which skills the model is offered",
738
          "  /export                     write this conversation as an ATIF trajectory",
739
          "  /reload                     rebuild and restart on the current source",
740
          "  /delegate [<n>x] <prompt>   run child agents on a prompt",
741
          "",
742
          "Keys",
743
          "  enter                       send · steer a running turn",
744
          "  shift+enter                 queue for when the turn ends",
745
          "  esc                         interrupt the reply · clear the composer",
746
          "  tab                         switch model",
747
          "  shift+tab                   change how hard it thinks",
748
          "  ctrl+o                      expand a tool call",
749
          "  ctrl+x                      stop the children",
750
          "  pgup / pgdn                 scroll the transcript",
751
          "  ctrl+c                      stop · ctrl+d  quit",
752
        ].join("\n"),
753
      );
754
      this.emit();
755
      return;
756
    }
757
758
    // `/export` is not a turn either: it writes what has already happened.
759
    if (/^\/export\s*$/.test(prompt.trim())) {
760
      this.entries.push({ role: "you", text: prompt, settled: true, at: Date.now() });
761
      try {
762
        const written = exportTrajectory(this.snapshot(), {
763
          model: this.source.modelId ?? this.source.model,
764
          toolDefinitions: this.source.toolDefinitions?.(),
765
          version: VERSION,
766
          ...(this.exports === undefined ? {} : { directory: this.exports.directory, copy: false }),
767
        });
768
        this.notice(
769
          `Exported ${String(written.steps)} step${written.steps === 1 ? "" : "s"} as ATIF to ${written.path}` +
770
            (written.copied ? " (path copied to the clipboard)." : "."),
771
        );
772
      } catch (cause) {
773
        this.notice(
774
          `The export could not be written: ${cause instanceof Error ? cause.message : String(cause)}`,
775
        );
776
      }
777
      this.emit();
778
      return;
779
    }
780
726 781
    // `/help` was going to the model, which answered with nothing. The keys and
727 782
    // the commands are the interface's own facts and it should not have to ask
728 783
    // anything to state them.
packages/openagents-cli/src/coder-ui.ts modified +20 -71

@@ -161,25 +161,18 @@ function justify(left: string, right: string, width: number): string {

161 161
  return left + " ".repeat(width - used) + right;
162 162
}
163 163
164
/** A key hint. A pinned one is kept even when the row has to give something up. */
164
/** A key hint. */
165 165
interface Hint {
166 166
  readonly text: string;
167
  readonly pinned?: boolean;
168 167
}
169 168
170 169
/**
171
 * Lay out the key hints against the counter, dropping hints from the end until
172
 * the row fits.
170
 * Lay out the key hints, dropping them from the end until the row fits.
173 171
 *
174
 * The counter is state the reader is trying to read; the hints are reminders
175
 * of keys that work whether or not they are printed. So the hints are what
176
 * gives way. Padding the two apart and dropping the counter instead is how a
177
 * wide-enough terminal still managed to hide the reply count.
178
 *
179
 * A pinned hint outranks the counter, because a key that stops fifteen agents
180
 * from spending is not a reminder. Ordering the hints so the stop came before
181
 * the conveniences was not enough: at eighty columns the counter grows as the
182
 * transcript scrolls, and the row dropped every hint at once.
172
 * Only the skills screen shows these now: the chat's keys moved into `/help`,
173
 * where a reader looks for them once rather than past them always. A hint that
174
 * outranked everything else on the row went with that move — it existed for the
175
 * key that stops the fleet, and the fleet's row is no longer here.
183 176
 */
184 177
function hints(keys: ReadonlyArray<Hint>, right: string, width: number): string {
185 178
  const shown = [...keys];

@@ -188,12 +181,8 @@ function hints(keys: ReadonlyArray<Hint>, right: string, width: number): string

188 181
    if (shown.length > 0 && visibleWidth(left) + visibleWidth(right) + 2 <= width) {
189 182
      return justify(left, right, width);
190 183
    }
191
    const droppable = shown.reduce<number>(
192
      (last, key, index) => (key.pinned === true ? last : index),
193
      -1,
194
    );
195
    if (droppable < 0) break;
196
    shown.splice(droppable, 1);
184
    if (shown.length === 0) break;
185
    shown.pop();
197 186
  }
198 187
199 188
  const plain = shown.map((key) => key.text).join(" · ");

@@ -602,7 +591,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

602 591
        paint(rows, rows.length, 1);
603 592
        return;
604 593
      }
605
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS - 1);
594
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS);
606 595
607 596
      const fleet = fleetLines(snapshot, width);
608 597
      // The fleet takes its rows from the transcript, not from the chrome: the

@@ -615,8 +604,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

615 604
616 605
      const maxStart = Math.max(0, lines.length - transcriptRows);
617 606
      const start = anchor === undefined ? maxStart : Math.min(anchor, maxStart);
607
      // Kept for the status line: a reader scrolled up with nothing saying so
608
      // reads a still transcript as a stopped session.
618 609
      const above = start;
619
      const below = Math.max(0, lines.length - start - transcriptRows);
620 610
621 611
      const rows: string[] = [];
622 612
      for (let row = 0; row < transcriptRows; row += 1) rows.push(lines[start + row] ?? "");

@@ -640,8 +630,10 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

640 630
      // The fleet is named on the status line even though the block above lists
641 631
      // it, because the block is what gives way first on a short terminal and
642 632
      // the count is the part the reader is waiting on.
633
      const scrolled = anchor === undefined ? "" : `${DIM} · scrolled ↑${String(above)}${RESET}`;
643 634
      const activity =
644
        phrase === undefined ? chatActivity : `${chatActivity} ${DIM}· ${phrase}${RESET}`;
635
        (phrase === undefined ? chatActivity : `${chatActivity} ${DIM}· ${phrase}${RESET}`) +
636
        scrolled;
645 637
      // Dropped from the left as the terminal narrows, because that is the
646 638
      // order of what a reader cannot recover elsewhere: they can see which
647 639
      // checkout they are in, they can ask git for the branch, and nothing on

@@ -689,49 +681,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

689 681
      // and a caption sits outside the thing it describes.
690 682
      rows.push(`  ${justify(activity, where, inner)}`);
691 683
692
      // Every key named here does something in the state it is named in, and
693
      // they are listed in the order a reader needs them, because a narrow row
694
      // drops them from the end. An earlier version offered "esc esc to
695
      // interrupt" while idle, where there was nothing to interrupt.
696
      const keys: Hint[] = [];
697
      if (snapshot.running) {
698
        keys.push(
699
          { text: "enter to steer" },
700
          { text: "shift+enter to queue" },
701
          { text: "esc to interrupt" },
702
        );
703
      } else {
704
        keys.push({ text: "enter to send" });
705
        if (composer.length > 0) keys.push({ text: "esc to clear" });
706
        else keys.push({ text: "ctrl+d to quit" });
707
      }
708
      // Stopping the fleet is pinned rather than merely early: the row is
709
      // clipped from the end and this hint only appears while children are
710
      // spending, so an unpinned one went exactly when it applied.
711
      if (snapshot.tasks.some((task) => task.status === "running")) {
712
        keys.push({ text: "ctrl+x to stop agents", pinned: true });
713
      }
714
      // Only when there is another model to switch to, and only while nothing
715
      // is running: a turn already accepted keeps the backend it named.
716
      if (session.canCycleBackend && !snapshot.running) keys.push({ text: "tab to switch model" });
717
      if (session.canCycleReasoning && !snapshot.running) {
718
        keys.push({ text: "shift+tab to change thinking" });
719
      }
720
721
      if (focusedTool(snapshot) !== undefined) keys.push({ text: "ctrl+o to expand" });
722
723
      // `this run` is not decoration. The count is this process's, and a
724
      // source that is not the thread — the stand-in behind `--offline` — has
725
      // no ceiling the number could be read against, so an unlabelled figure
726
      // would invite the reader to compare it with a budget beside it.
727
      const replies = `${snapshot.turns} ${snapshot.turns === 1 ? "reply" : "replies"} this run`;
728
      const counter =
729
        anchor !== undefined
730
          ? `${DIM}scrolled · ↑${above} · ↓${below}${RESET}`
731
          : above > 0
732
            ? `${DIM}↑${above} above · ${replies}${RESET}`
733
            : `${DIM}${replies}${RESET}`;
734
      rows.push(`  ${hints(keys, counter, inner)}`);
684
      // One row of chrome under the composer, and it is the status line. The
685
      // keys used to live on a second row; they are in `/help` now, which is
686
      // where a reader looks for them once rather than past them always.
735 687
736 688
      paint(rows, transcriptRows + fleet.length + 3, 4 + [...visible].length + 1);
737 689
    };

@@ -1151,12 +1103,9 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

1151 1103
    stdin.on("data", onData);
1152 1104
    stdout.on("resize", onResize);
1153 1105
1154
    session.notice(
1155
      "openagents coder — development build. Type a message and press enter. " +
1156
        "Ctrl+D quits, Esc interrupts a reply. `/system` shows what the model is told, " +
1157
        "`/skills` chooses which skills it is offered, `/export` writes the conversation " +
1158
        "as ATIF, `/reload` restarts on the current source.",
1159
    );
1106
    // No banner. Four lines of keys and commands at the top of every session is
1107
    // four lines a reader scrolls past for the rest of it, and `/help` says the
1108
    // same thing when it is wanted.
1160 1109
    render();
1161 1110
  });
1162 1111
}
packages/openagents-cli/test/coder-ui.test.ts modified +45 -102

@@ -171,60 +171,13 @@ describe("runCoderUi", () => {

171 171
    expect(painted).toContain("\x1b[2m\x1b[3mI should check first.\x1b[0m");
172 172
  });
173 173
174
  it("counts the streaming turn, so the bar never reads zero under a live reply", async () => {
175
    const stdin = new FakeIn();
176
    const stdout = new FakeOut();
177
    let release = () => {};
178
    const held = new Promise<void>((resolve) => {
179
      release = resolve;
180
    });
181
    const paused: ReplySource = {
182
      model: "scripted",
183
      async *reply() {
184
        yield { type: "text", value: "still arriving" } as const;
185
        await held;
186
      },
187
    };
188
189
    const session = new CoderSession(paused, "repo", "main");
190
    const running = runCoderUi(session, {
191
      stdin: stdin as unknown as NodeJS.ReadStream,
192
      stdout: stdout as unknown as NodeJS.WriteStream,
193
    });
194
    const turn = session.submit("go");
195
    await new Promise((resolve) => setTimeout(resolve, 0));
196
197
    expect(session.snapshot().running).toBe(true);
198
    const bar = screen(stdout.written).at(-1) ?? "";
199
    expect(bar).toContain("1 reply this run");
200
    expect(bar).not.toContain("0 replies");
201
202
    release();
203
    await turn;
204
    stdin.emit("data", "\x04");
205
    await running;
206
  });
207 174
208
  it("labels the count as this run's, because the conversation is not", async () => {
209
    const { rows } = await drive([{ type: "text", value: "hello" }]);
210
    expect(rows.at(-1)).toContain("1 reply this run");
211
  });
212 175
213 176
  it("says nothing about scope when the source keeps its turns to itself", async () => {
214 177
    const { rows } = await drive([{ type: "text", value: "hello" }]);
215 178
    expect(rows.join("\n")).not.toContain("shared with");
216 179
  });
217 180
218
  it("offers no key in the bottom bar that does nothing in that state", async () => {
219
    const { rows } = await drive([{ type: "text", value: "hello" }]);
220
    const bar = rows.at(-1) ?? "";
221
    expect(bar).toContain("enter to send");
222
    expect(bar).toContain("ctrl+d to quit");
223
    // There is nothing to interrupt while the session is idle.
224
    expect(bar).not.toContain("interrupt");
225
    // And nothing to switch to, because this source has one backend.
226
    expect(bar).not.toContain("tab to switch");
227
  });
228 181
229 182
  describe("switching backend with tab", () => {
230 183
    const driveSwitchable = async (keys: ReadonlyArray<string>) => {

@@ -248,10 +201,6 @@ describe("runCoderUi", () => {

248 201
      return { painted, rows: screen(painted), session };
249 202
    };
250 203
251
    it("names the key only where there is another backend to reach", async () => {
252
      const { rows } = await driveSwitchable([]);
253
      expect(rows.at(-1) ?? "").toContain("tab to switch model");
254
    });
255 204
256 205
    it("moves to the next backend and says so", async () => {
257 206
      const { session, rows } = await driveSwitchable(["\t"]);

@@ -313,57 +262,6 @@ describe("runCoderUi", () => {

313 262
    expect(status).toContain("$2.00");
314 263
  });
315 264
316
  it("keeps the stop key on a narrow row that has run out of room", async () => {
317
    const stdin = new FakeIn();
318
    const stdout = new FakeOut();
319
    stdout.columns = 80;
320
    const registry = new CoderTaskRegistry();
321
    const session = new CoderSession(
322
      source([
323
        {
324
          type: "text",
325
          value: Array.from({ length: 40 }, (_, index) => `line ${index}`).join("\n"),
326
        },
327
      ]),
328
      "repo",
329
      "main",
330
      {
331
        registry,
332
        fleet: {
333
          submit: async () => ({ status: "refused", code: "empty_prompt", reason: "not used" }),
334
        },
335
        label: "fake (fake/model)",
336
      },
337
    );
338
    const running = runCoderUi(session, {
339
      stdin: stdin as unknown as NodeJS.ReadStream,
340
      stdout: stdout as unknown as NodeJS.WriteStream,
341
    });
342
343
    // A transcript long enough to scroll grows the counter on the right, which
344
    // is what used to push every hint off an eighty-column row.
345
    await session.submit("go");
346
    const task = registry.register(
347
      {
348
        id: "d1",
349
        description: "run the long build",
350
        prompt: "build",
351
        agent: "opencode",
352
        model: "fake/model",
353
        cwd: "/tmp",
354
        background: true,
355
      },
356
      0,
357
    );
358
    registry.start(task.id, new AbortController());
359
    await new Promise((resolve) => setTimeout(resolve, 20));
360
    const rows = screen(stdout.written);
361
    stdin.emit("data", "\x04");
362
    await running;
363
    session.close();
364
365
    expect(rows.at(-1) ?? "").toContain("ctrl+x to stop agents");
366
  });
367 265
368 266
  it("keeps a long typed line inside the row, showing its tail", async () => {
369 267
    const stdin = new FakeIn();

@@ -965,3 +863,48 @@ describe("the transcript's marker column", () => {

965 863
    expect(painted).not.toContain("pgup/pgdn to scroll");
966 864
  });
967 865
});
866
867
describe("the chrome under the composer", () => {
868
  it("is one row, and it is the status line", async () => {
869
    const { rows } = await drive([{ type: "text", value: "answer" }]);
870
    const bottom = rows.slice(-2);
871
872
    // The keys used to have a row of their own under the status line. They are
873
    // in `/help` now, which is where a reader looks for them once rather than
874
    // past them always.
875
    expect(bottom.some((row) => row.includes("ready") || row.includes("working"))).toBe(true);
876
    expect(rows.join("\n")).not.toContain("enter to send");
877
    expect(rows.join("\n")).not.toContain("ctrl+d to quit");
878
  });
879
880
  it("opens without four lines of keys nobody asked for", async () => {
881
    const stdin = new FakeIn();
882
    const stdout = new FakeOut();
883
    const session = new CoderSession(source([]), "repo", "main");
884
    const running = runCoderUi(session, {
885
      stdin: stdin as unknown as NodeJS.ReadStream,
886
      stdout: stdout as unknown as NodeJS.WriteStream,
887
    });
888
889
    // A banner at the top of every session is something scrolled past for the
890
    // rest of it.
891
    expect(stdout.written).not.toContain("development build");
892
    expect(session.snapshot().entries).toEqual([]);
893
894
    stdin.emit("data", "\x04");
895
    await running;
896
  });
897
898
  it("says when the reader is scrolled away from the newest line", async () => {
899
    const { rows } = await drive(
900
      Array.from({ length: 80 }, (_unused, at) => ({
901
        type: "text" as const,
902
        value: `line ${String(at)}\n`,
903
      })),
904
    );
905
906
    // Losing the second row lost the scroll indicator with it, and a still
907
    // transcript with nothing saying why reads as a stopped session.
908
    expect(rows.join("\n")).toContain("ready");
909
  });
910
});

This page updates live while a promote is in flight · changelog