A History Browsing Prompt

Now that we've come up to speed with the new beta, it's time to continue our journey into customizing the prompt. It’s been almost a month since I promised it, and a lot of you are already doing great things with your custom profile.

For reference sake, this is our current prompt function, as defined in profile-custom.msh:

function prompt
   $currentDirectory = get-location
   "MSH $currentDirectory >"

That prompt looks like:

MSH C:\Documents and Settings\Lee >

Ideally, we want it to act more like this:

MSH:1 C:\Documents and Settings\Lee ># some command
MSH:2 C:\Documents and Settings\Lee ># some other command
MSH:3 C:\Documents and Settings\Lee >"Hello World"
Hello World
MSH:4 C:\Documents and Settings\Lee ># yet another command
MSH:5 C:\Documents and Settings\Lee >invoke-history 3
"Hello World"
Hello World

By giving the history ID in the prompt, you can re-invoke those commands without having to use your keyboard’s arrow keys to cycle through the history buffer. Notice how I was able to use the "invoke-history" cmdlet to execute the command I saw on that line.

So how do we go about updating the prompt to include the line number?

Largely, the secret lays in the "get-history" cmdlet. Try it now, and see what it shows:

MSH:6 C:\Documents and Settings\Lee >get-history
Id $_ CommandLine
-- -- -----------
1 C # some command
2 C # some other command
3 C "Hello World"
4 C # yet another command
5 C "Hello World"

At first blush, it seems as though the simplest way to get your history count would be something like this, knowing that get-history returns a list of history items. We already know how to get the count of items in a list:

MSH:7 C:\Documents and Settings\Lee >(get-history).Count

That’s a great first guess, but it unfortunately suffers from two major flaws, and a minor flaw.

Major Flaw 1: get-history’s -count parameter.

By default, get-history only returns the last 30 items in your history. You could work around that problem by specifying some huge value (ie: 9999) for the count parameter, but there is a cleaner solution: leverage the Id of the last command executed.

Major Flaw 2: get-history doesn’t always return an array.

Since cmdlets can write any number of objects to the pipeline, the infrastructure decides how to output the results on a case-by-case basis. The first time you call get-history from a clean shell, you have no history. In that situation, the cmdlet returns no items – so the shell doesn’t write anything out. The second time you call get-history (from the clean shell,) your history contains only one item. In that case, the shell writes the individual object out. From the third time on, your history contains two or more items. The get-history cmdlet dutifully writes them out, and the shell finally returns them as an array:

MSH:1 C:\Documents and Settings\Lee >get-history
MSH:2 C:\Documents and Settings\Lee >get-history
Id $_ CommandLine
-- -- -----------
1 C get-history

MSH:3 C:\Documents and Settings\Lee >get-history
Id $_ CommandLine
-- -- -----------
1 C get-history
2 C get-history

Of course, we have a way of working around this as well, or I wouldn’t bring it up!

Minor Flaw 3: We need to display a history Id from the future.

The Id of the last command (or total history count) isn’t exactly what you want. If you use the Id of the last command (or total history count,) the number will not represent the command you are about to execute. In that case, you won’t be able to use the numbers as parameters to invoke-history.

Here’s the final solution that works around these problems, heavily commented for clarity:

function prompt
   ## Get the history. Since the history may be either empty,
   ## a single item or an array, the @() syntax ensures
   ## that Monad treats it as an array
   $history = @(get-history)

   ## If there are any items in the history, find out the
   ## Id of the final one.
   ## Monad defaults the $lastId variable to '0' if this
   ## code doesn't execute.
   if($history.Count -gt 0)
      $lastItem = $history[$history.Count - 1]
      $lastId = $lastItem.Id

   ## The command that we're currently entering on the prompt
   ## will be next in the history. Because of that, we'll
   ## take the last history Id and add one to it.
   $nextCommand = $lastId + 1

   ## Get the current location
   $currentDirectory = get-location

   ## And create a prompt that shows the command number,
   ## and current location
   "MSH:$nextCommand $currentDirectory >"

That gets you a very functional history-browsing prompt, and is just about the one that I use on all of my machines. In an upcoming post, I’ll talk about some other things you might want to do with your prompt.

And for those of you who are becoming more familiar with the shell, one of my older questions still stands:

What idioms from other languages / shells do you find the hardest to un-train yourself from? For example, "cd.." is a valid command in DOS, but not MSH. What have you found the hardest to discover? What did you do to try and discover it? I'm looking for things like,

"I couldn't figure out how to use a hash table, so I typed get-command *hashtable*"

[Edit: Monad has now been renamed to Windows PowerShell. This script or discussion may require slight adjustments before it applies directly to newer builds.]

9 Responses to “A History Browsing Prompt”

  1. Travis writes:

    My main issue with Monad (and cmd.exe for that matter) is how tab completion is handled. If I "cd my[tab]" it changes to "cd ‘My Documents’". Bash inserts a trailing slash, making changing into deeper levels easier.

    In Bash, tab completion will only complete if the match is unique, otherwise it can list the possibilities. In MSH, if I’m in My Documents and I do "cd My[tab]" it cycles through all of the possible matches that start with "My". In Bash, "cd My[tab][tab]" yields a list of all possibilities: My Movies, My Music, etc. It’s up to me to type more to make it unique.

    I would *love* to be able to configure Monad’s tab completion behavior! I don’t think it should be changed by default (too many customers are used to the current behavior), but making it an option would be great for people who grew up on Bash. Maybe let us create a tab function (like the prompt function).

  2. aim48 writes:

    I have started monad quite a few times and have found myself lost – becuase I use extensive cmd commands

    Something as simple as cd\directory would not work because there is no space before the \. So I have given up on MSH for now.

    Suggestion: Have a cmd session running in the background and whenever a command comes thru that msh cannot handle – pass it thuru to the cmd prompt and echo the cmd output to the msh screen – then syncronize the directories between cmd and msh

  3. Roo writes:

    I cannot agree more with Travis, even if I am not used to work with BASH.

    Is there a reason the current Microsoft shells do not include a trailing slash after folder names?

  4. Lee Holmes writes:

    Aim; If you have any questions on what a Monad equivalent to a cmd.exe command would be, please feel free to ask. Whenever you move to a new shell (cmd.exe <-> bash included,) there will always be idioms that take awhile to re-wire out of your system.

    If cd\directory is extremely important to you, this function should help.

    Put this in your profile-custom.msh, just above your prompt function:

    function replaceCdHabit
    $history = @(get-history)
    $historyItem = $history[$history.Count – 1]
    $commandLine = $historyItem.CommandLine
    if($commandLine.ToLower().IndexOf("cd\") -eq 0)
    $newDirectory = $commandLine.Substring(2,$commandLine.Length – 2)
    set-location $newDirectory

    then, in your prompt function, make this the first line:

    function prompt

    ## Get the history. Since the history may be either empty,
    ## a single item or an array, the @() syntax ensures

    Hope this helps,

  5. Rahul writes:

    you could use measure-object to find out the max of history id

    function prompt
    $maxhid=$(get-history | measure-object -p id max).max.value
    return "MSH:{0}>" -F $maxhid

  6. Peter Provost writes:

    Why not this?

    function prompt
    $nextId = $(get-history -count 1).Id + 1
    "MSH:" + $nextId + " " + $(get-location).Path + " > "

  7. Tommy Williams writes:

    On idioms I miss:

    (1) dir /a:d to get a list of just the directories, or dir /o:-d to sort the directory output in reverse chronological order. There’s just way too much typing in Monad to achieve these things.

    (2) Using findstr to find a line in output. I particularly find myself wanting to use this with get-alias: alias | findstr /i content to find anything that relates to content, either in the name or the definition. But, since cmdlets output objects rather than strings, I have to do alias | out-string | findstr /i content. I use out-string so much that, in my custom profile, I’ve aliased it to the single letter "s". I use it for more than searching: I like to pipe the man pages to a more-capable pager than more, such as less, or to my editor, vim: man get-alias | view – but, again, I have to use man get-alias | out-string | view – or, slightly shortened, get-alias | s | view –

    Sometimes I really miss having text as output in Monad.

  8. Tommy Williams writes:

    Here’s another thing that I can’t do as succinctly in MSH.

    I often have files with dates as part of their filename. Say, file20050701.txt, file20050702.txt, etc. — one for each day of July.

    I create a directory, July2005, and I want to move all the July files in it.

    In cmd.exe, I would do:

    mv file200507* July2005

    But MSH won’t let me do it. Instead, I have to resort to:

    dir file200507* | foreach {mv $_ July2005}

  9. Lee Holmes writes:

    Tommy: That’s a bug we’re aware of, and are fixing for the next drop.


Leave a Reply